Skip to content

feat(objectql,platform-objects): keep a by-id delete's rows in the recycle bin (ADR-0148) - #30780

Open
objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/project-thread-jf62r6
Open

objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/project-thread-jf62r6

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of #30237: the first of five PRs in the seat's split (claim amendment 6673429400). This one adds the object, the snapshot on delete, the retention stamp and the TTL reap. Still on the card: purge, empty and permanent delete (PR 2), restore (PR 3), the permission reader for allowRestoreOwnDeleted / manageRecycleBin (PR 4) and the REST doors (PR 5).

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin)

维护者速读

  • 改了什么:按 id 删除一条业务记录时,引擎先把这条记录和它的主从级联子记录(主记录在前)连同被置空的引用快照进新表 sys_recycle_bin,一次删除一条,然后照旧物理删除;生命周期清理器在每条的 purge_after 到期后删掉它。
  • 为什么改:ADR-0148 回收站的引擎前半段:没有快照就谈不上恢复。恢复、清除、权限和 REST 接口在后面四个 PR。
  • 风险与回滚:每次按 id 删除业务记录都会多读一遍级联行、多写一行;回收站表写失败时整个删除会被拒(不丢快照)。保留期配置错了也会拒删,环境变量配错时启动就会失败。按谓词删除(含生命周期清理)、级联子记录、平台对象(sys_*、cloud_*、ai_*)不进回收站;回收站条目自身的清理、置空和随组织删除都不写审计账本。回滚就是 revert 本 PR,已写入的条目只是一张没人读的表。
  • 先看:packages/objectql/src/recycle-bin/capture.ts(快照与写入、补偿)、packages/objectql/src/engine.ts 的 runByIdDelete(接入点)、packages/objectql/src/engine-cascade-planning.ts(onCollected 与置空引用记录)、packages/plugins/plugin-audit/src/audit-writers.ts 的 SKIP_OBJECTS。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run --project local src/recycle-bin/,预期 2 个文件、47 个测试全过。
  • 需要你做的:无,席位落地。

What changes

@objectstack/platform-objects: sys_recycle_bin (system/sys-recycle-bin.object.ts), registered by PlatformObjectsPlugin beside sys_secret.

  • Its fields are the spec's RECYCLE_BIN_ENTRY_FIELDS, key for key, plus the tenant column. Each entry is spelled through its builder (Field.text({ ...RECYCLE_BIN_ENTRY_FIELDS.object_name, maxLength: 255 })) rather than spread in, because check:keyed-text-bounds refuses a spread and reads a keyed text column's bound only from a literal. The two keyed text bounds are restated, and plugin.test.ts pins every entry equal to the spec's, so the table and RecycleBinEntrySchema cannot drift.
  • It is managedBy: 'engine-owned', has access: { default: 'private' }, and serves no generic data-API method (apiEnabled: false, apiMethods: []): the snapshot holds every field of the deleted rows, so it is read only through the doors of PR 4 and PR 5, under the live object's FLS.
  • Its tenant column organization_id declares deleteBehavior: 'cascade' (seat ruling Q4): an organization's delete takes its entries with it, and the entries are never counted against that delete.
  • lifecycle: { class: 'transient', ttl: { field: 'purge_after', expireAfter: '0h' } }: the sweep reaps an entry once its stamped deadline passes, and asks no resolver.

@objectstack/objectql: the write side, in recycle-bin/capture.ts (the engine member is a delegation, per the facade gate).

  • delete()'s by-id branch, inside runByIdDelete, so it shares the ObjectQL.delete's single-id cascade is not transactional — a refusal mid-cascade leaves earlier children deleted while the response says the delete failed #7413 unit of work:
    1. openRecycleBinCapture runs before the cascade, and opens nothing when the caller asked for multi: true. A retention defect refuses the delete here, before any write.
    2. cascadeDeleteRelations takes a new onCollected callback. It runs once with the freshly collected set, after the objectql: a lookup with deleteBehavior: "restrict" is overridden when the same parent is also the child's master_detail target — deleting the parent cascade-deletes the child instead of refusing 409 #26718 restricts are judged and before the walk writes. The capture reads each member's row raw, inside the unit, in BFS order from the root along cascadeEdges (master first). It keeps declared columns only, leaves out secret columns (D4) and skips sys_* members.
    3. The walk's set_null branch appends { object, id, field, value } to the new optional CascadeDeleteSet.clearedReferences for both arities (D2's re-link input).
    4. After the walk, and before driver.delete(root), ONE entry is written raw through the bin's driver with this unit's driver options. If removing the root row throws, the entry goes with it: the transaction's rollback when it covered the bin, else the capture's compensation (logged at error if that fails too).
  • deleted_by: the authorized subject, else attributedUserId (the audit ledger's precedence). organization_id: the root row's tenant column, else the context's.
  • Retention (D3, seat ruling Q2): ObjectQLPlugin({ recycleBinRetention: { resolve, token } }) → engine.setRecycleBinRetention. The resolver is asked once per delete with the host's token. When it answers undefined: OS_RECYCLE_BIN_RETENTION_DAYS, else 30. A bad answer is refused with the spec's recycleBinRetentionRefusal text. The variable is also read once in init(), so a bad value stops the boot.
  • What does NOT enter the bin (seat ruling Q6, and the seat's ruling 6675653517 on the review's question 7):
    • predicate deletes (multi: true), lifecycle reaps included. The engine routes a delete by its id, so the sweep's per-row where: { id }, multi: true reap reaches the by-id branch; the capture is told apart by the caller's own flag;
    • cascade children, which are rows of the root's entry;
    • platform objects, meaning names in the sys_, cloud_ or ai_ namespace (hasPlatformObjectPrefix, the tenancy census's population);
    • a composition without the bin, which logs one info line per engine. That is reduced functionality, not a lost write.

@objectstack/spec (both hunks consented by domain:spec#3 in 6673450505 on mailbox PR #30658):

  • sys_recycle_bin joins PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'], in the same commit (d374cbe5) as the object file, its registration and the ownership-inventory row. The tenancy census row followed in 819b02f7, after its gate reported the drift.
  • The // comment above RECYCLE_BIN_OBJECT in recycle-bin.zod.ts now says in the present tense where the object file lives. No .describe(), TSDoc, shape or export changed, and the module header is untouched.

@objectstack/plugin-audit: sys_recycle_bin joins SKIP_OBJECTS (group 2, beside sys_job_queue / sys_upload_session, with its reason; amendment 6675653517). The generic writer would otherwise copy the whole entry, snapshot and its internal: true columns included, into sys_audit_log and sys_activity on the sweep's reap, on the set_null of deleted_by and on an organization delete's cascade (ADR-0057 §3.3, ADR-0148 D4, the #30751 precedent). The business delete an entry was taken for is audited as before.

@objectstack/cli: the organization-ownership inventory row for sys_recycle_bin (fate attribution, like sys_exchange_rate).

Census rows:

  • federated-injected-column-readers.test.ts gains a READERS row for the new resolveTenantFieldName() use (row-value, the realtime publisher's disposition).
  • scripts/platform-object-tenancy-census.json is regenerated with --write: sys_recycle_bin joins the population, in reach by organization_id (registered 93 → 94, in reach 47 → 48).
  • One new ADR anchor: scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json.

Tests

  • packages/objectql/src/recycle-bin/retention.test.ts (26 cases):
    • resolver → variable → 30;
    • the token is passed through;
    • every defect from either source is refused with the spec sentence, never with the default;
    • the boot read;
    • the purge_after arithmetic.
  • packages/objectql/src/recycle-bin/capture.test.ts (21 cases) runs a real ObjectQL on a driver with real rollback:
    • one entry per by-id delete: rows master first, both set_null arities in clearedReferences, deleted_by, organization_id, the schema-valid entry;
    • purge_after = deleted_at + retention;
    • attribution of a system-authorized delete;
    • the secret column left out, and undeclared columns left out;
    • the entry carries the same transaction handle as the root delete;
    • no entry for a cascade child, a predicate delete, a by-id delete asked with multi: true, a LifecycleService.sweep() reap of a business object that declares a lifecycle, a sys_ / cloud_ / ai_ object, or a composition with no bin (one notice);
    • an organization's delete removes its entries, is not refused by them, and leaves another organization's entries (ruling Q4);
    • the resolver's days and token are stamped, and a defect refuses before any write;
    • a failed root removal: the entry is rolled back with the transaction, or compensated without one;
    • the TTL sweep reaps only due entries and asks no resolver;
    • the BFS order helper.
  • packages/plugins/plugin-audit/src/audit-writers.test.ts (3 cases): the reap of an entry and the update that clears its deleted_by write no ledger or activity row, and the business delete still writes both.
  • packages/platform-objects/src/plugin.test.ts: the registration list includes SysRecycleBin, and a new case pins its fields equal to the spec's RECYCLE_BIN_ENTRY_FIELDS (both sides through ObjectSchema.create) plus the cascading tenant lookup.

Acceptance notes

  • The card's Wanted items in this slice: snapshot before the physical delete (business objects only), TTL reap on the LifecycleService, every read path untouched.
  • Restore, purge, the grants and the doors are the later PRs.
  • Forward-only (D4): deletes before this release have no entry. The changeset says so, and says which deletes skip the bin.

Verification

Head 6a07be61. The contract review of 819b02f7 (6675639559) failed, and this head fixes both must-fixes, the seat's ruling on question 7 and should-fixes (a) to (d).

  • Gates on 6a07be61: node scripts/pm/dispatch-gates.mjs --ran reports 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN. Every family exited 0, including the two check-tenant-audit-census families that plugin-audit adds, and pnpm lint exited 0. The earlier rounds, on 136ad85d and 819b02f7, found three reds, fixed in 819b02f7: the declaration shape for check:keyed-text-bounds, the test double's bound, and the tenancy census.
  • Typecheck: pnpm --filter PKG typecheck exits 0 for @objectstack/objectql, @objectstack/plugin-audit and @objectstack/platform-objects.
  • Tests:
    • the objectql suite: Test Files 559 passed (559), Tests 11132 passed (11132);
    • the plugin-audit suite: Test Files 75 passed (75), Tests 1164 passed (1164);
    • src/recycle-bin/: 47/47;
    • platform-objects plugin.test.ts: 23/23.
  • Reverse verification, with scripts/ablation-replace.mjs. Every suite imports src relatively, and every restore printed blob == HEAD and an empty git diff HEAD.
    • openRecycleBinCapture(...) replaced by undefined: capture.test.ts fails 11 of 16 (at 819b02f7).
    • object_name's bound restated as 254: the field pin fails.
    • 'sys_recycle_bin' removed from SKIP_OBJECTS: 2 of the 3 new plugin-audit cases fail. The business control stays green.
    • The capture's multi flag forced to false: the multi: true by-id case and the LifecycleService.sweep() case fail.
    • recycleBinTakesObject narrowed back to sys_ only: the cloud_thing and ai_thing cases fail.

Generated by Claude Code

…cycle bin

ADR-0148, first slice: the sys_recycle_bin object, the snapshot on
delete, the retention stamp and the TTL reap.

- platform-objects registers sys_recycle_bin, built from the spec's
  RECYCLE_BIN_ENTRY_FIELDS: engine-owned, no generic data-API method,
  its tenant column cascades, ttl on purge_after.
- objectql: a by-id delete of a business record writes one entry in its
  own unit of work, before the root row goes: the cascade set read raw
  before the walk (master first, secret columns left out), the set_null
  references the walk cleared, deleted_by/deleted_at, retention_days
  and purge_after. A removal that fails takes the entry with it.
- retention: ObjectQLPlugin's recycleBinRetention { resolve, token },
  else OS_RECYCLE_BIN_RETENTION_DAYS (read at init), else 30 days; a
  value that is not a positive whole number is refused.
- spec: sys_recycle_bin joins PLATFORM_OBJECTS_BY_PACKAGE; cli: its
  organization-ownership inventory row.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…e was made for

deleted_by reads the authorized subject first, then the attributed
user of a system-authorized write, the precedence the audit ledger uses.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…ile lives

The comment above RECYCLE_BIN_OBJECT described the object file as landing
later; it lands with this change, so the comment now names it. The
changeset records the skills follow-up that is owed, not moved.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…n shapes the source scans read

check:keyed-text-bounds refuses a spread inside `fields` and a `name` that
is not a string literal, so sys_recycle_bin now spells each spec entry
through its builder (restating the two keyed text bounds) and names itself
literally. The plugin test pins every entry equal to the spec's, so the
restatement cannot drift. The tenancy census is regenerated for the new
in-reach object, and the capture test's driver double applies a caller's
bound after its filter and before it copies rows.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data protocol:system tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/platform-objects, @objectstack/plugin-audit, @objectstack/spec, touching 61 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/platform-objects/src/system/index.ts, packages/spec/src/data/recycle-bin.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

62 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c17719dd6c0fbf364869872527f81a3e81d30f6a.

⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/platform-objects/src/system/index.ts, packages/spec/src/data/recycle-bin.zod.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages), sys_user (literal, 49 pages)
  • 14 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 182 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c17719dd6c0fbf364869872527f81a3e81d30f6a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f191da2ede482cf6a64b39fe0bafd500ffa8e6f0 — the merge of head 2fa6d1eac3076d760f879fe76afdde0d30705a5b into base c17719dd6c0fbf364869872527f81a3e81d30f6a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f191da2ede482cf6a64b39fe0bafd500ffa8e6f0 && git checkout f191da2ede482cf6a64b39fe0bafd500ffa8e6f0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c17719dd6c0fbf364869872527f81a3e81d30f6a 2fa6d1eac3076d760f879fe76afdde0d30705a5b && git checkout -B drift-repro c17719dd6c0fbf364869872527f81a3e81d30f6a && git merge --no-ff 2fa6d1eac3076d760f879fe76afdde0d30705a5b

node scripts/docs-audit/affected-docs.mjs --json c17719dd6c0fbf364869872527f81a3e81d30f6a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c17719dd6c0fbf364869872527f81a3e81d30f6a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 819b02f707dd76ef25254db10092706dd7b8f772
Local-runs: none
Scope: packages/spec/src/data/recycle-bin.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/platform-objects/src/system/sys-recycle-bin.object.ts, packages/platform-objects/src/system/index.ts, packages/platform-objects/src/plugin.ts, packages/platform-objects/src/plugin.test.ts, packages/objectql/src/recycle-bin/capture.ts, packages/objectql/src/recycle-bin/capture.test.ts, packages/objectql/src/recycle-bin/retention.ts, packages/objectql/src/recycle-bin/retention.test.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-cascade-planning.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/plugin.ts, packages/objectql/src/index.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/cli/src/utils/organization-ownership-inventory.ts, scripts/platform-object-tenancy-census.json, scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json, .changeset/30237-recycle-bin-snapshot-on-delete.md (widened to the whole non-prose diff; the cited code on main — engine-driver-options.ts buildDriverOptions, engine-cascade-planning.ts planCascadeAtomicity / walkReferencingRelations / isTenantColumnSetNull / outlivesOrganization / refuseOrganizationDeleteWhileOwned / organizationOwnedRowCounts, declared-read-columns.ts, engine-secret-internal-fields.ts, lifecycle/lifecycle-service.ts reap / batchedReap / effectiveWindowMs, lifecycle/duration.ts, plugin-audit audit-writers.ts SKIP_OBJECTS / writeAudit, spec/data/field.zod.ts Field, scripts/check-keyed-text-bounds.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, platform-objects/scripts/i18n-extract.config.ts, .changeset/config.json, docs/adr/0057 §3.3 as #30751's record cites it — was read, not reviewed)

Governing text: card #30237 body; unblock 6673225435; claim 6673233448 and its amendment 6673429400 (Q1, Q2, Q4, Q6 seat rulings, PR 1 file surface); the seat's plan plan-30237.md (PR 1 section, Q4/Q6, "no lost snapshot is silent"); spec#3 hand-over 6665087359 and consent 6673450505 on #30658 (hunks (a) and (b), terms); dev report 6675407579; ADR-0148 D1–D4; the PR body. Read-only shape held: git diff origin/main...head, files via git show, the card and every comment, the head's commits and check-runs through the API. PR user.login is objectstack-fleet[bot]; the PR is a draft; no auto-merge armed. Head verified: git fetch origin claude/project-thread-jf62r6 answers 819b02f7, five commits on main including one merge of main at 0a8d24ac.

Check-runs on this head (API, at review time): 61 runs, 36 success, 9 skipped, 16 still running, 0 red. Green: Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Plan Test Core shards, Dogfood Regression Gate, Auto Label, Check PR Size, Flag docs, Lint & Repo Gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · repo & tooling gates (the census checks), Lint · CI release & engine gates, Lint · docs & code guards, Lint · scoped (three), Type Check · source gates, Type Check · debt ledger, Type Check · consumer gates, Build Core, Build UI Deps, Build & E2E UI, Live E2E UI, Test Core 5/8, 6/8 and 8/8, Test UI 5, 7, 10, 11, 12, 14 and 16. Running: Test Core 1/8, 2/8, 3/8, 4/8 and 7/8, Type Check · workspace, Type Check UI, Test UI 1, 2, 3, 4, 6, 8, 9, 13 and 15. Running CI does not decide this record; what follows does, and neither must-fix below is one a green shard would contradict — no test in the tree reaps a bin entry under plugin-audit or sweeps a lifecycle-declaring business object.

The two packages/spec hunks against the consent.

  1. Hunk (a), platform-object-names.ts (+1): 'sys_recycle_bin' under PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'], between 'sys_platform_setting_audit' and 'sys_scim_connection_binding': sorted position, one line. Same-commit term, verified on the commit API, not the body: d374cbe5 holds the roster line, the object file, system/index.ts, plugin.ts and the ownership-inventory row (18 files). The census row is not in it: scripts/platform-object-tenancy-census.json moves only in 819b02f7, three commits later, after check:platform-object-tenancy-census reported the drift in the dev's first gate round. Four of the consent's five travel together; the census row trails. Cost: d374cbe5, 5c253f2a and 136ad85d are not bisect-clean for that gate; all five commits merge together, and a squash makes it moot. The purpose of the term — the roster never names a table that does not exist — holds at every commit, since the object file precedes or accompanies the line. Judged acceptable, named here as the consent asks; the PR body's sentence "in the same commit as the object file, its registration, the census row and the inventory row" is not what the commit API shows (should-fix, prose).
  2. Hunk (b), data/recycle-bin.zod.ts (+4 −4, the four // lines at :14-17 and nothing else). Present tense: it names the file (sys-recycle-bin.object.ts in @objectstack/platform-objects), that PlatformObjectsPlugin registers it and PLATFORM_OBJECTS_BY_PACKAGE lists it, and keeps the registry sentence in a respelled form ("that registry means "a table exists", and the name sits there because the table does"). The module header at :8-9 ("Nothing in this module writes or reads the bin at runtime") is byte-identical to main. Every .describe(), TSDoc block, shape and export is diff context; RECORD_RESTORED_EVENT's "NAMED, NOT YET EMITTED" and the cleared-reference should-fix sentence stay for PR 3, as consented. Nothing under packages/spec/generated, packages/spec/scripts or scripts/ renders a // comment, so there was nothing to regenerate. Holds.
  3. Nothing else under packages/spec: the diff's file list has exactly those two paths there.
  4. Terms: Clause-②: yes (widening), Metadata-protocol: added, a @objectstack/spec: minor changeset whose Skills-docs: line names skills/objectstack-api/SKILL.md :268, :417 as owed, not moved; skills/** untouched. Met.

① Derived judgments

  1. Roster (the schema face) widens. PLATFORM_PROVIDED_OBJECT_NAMES / isPlatformProvidedObjectName answer true for one more real object; nothing accepted before is refused. platform-object-names.test.ts on main holds the roster to the declared *.object.ts set both ways, and the declaration is in this PR. Right: widening, as declared.
  2. The object mirrors RECYCLE_BIN_ENTRY_FIELDS through builders, and the pin holds field for field. Field.text(config) is { type: 'text', ...config } and Field.lookup(reference, config) is { type: 'lookup', reference, ...config } (field.zod.ts :3461, :3558), so re-passing a built field through its own builder reproduces it key for key: the spread already carries the type (and reference: 'sys_user') the builder prefixes, with the same value. maxLength: 255 on object_name and record_id restates the spec's own 255 on both, so the restatement adds no key and changes no value. The pin runs both sides through ObjectSchema.create and toEquals fields minus organization_id; toEqual overlooks only undefined-valued keys, which neither side carries, and the dev's ablation (254) reds it. The tenant column is asserted separately (lookup, sys_organization, cascade) and name equals RECYCLE_BIN_OBJECT. Holds.
  3. Posture and tenancy, as ruled. isSystem: true, managedBy: 'engine-owned', access: { default: 'private' }, enable.apiEnabled: false, apiMethods: [], trackHistory: false, searchable: false; nameField: 'record_id'; indexes on (object_name, record_id), purge_after, deleted_by; lifecycle: { class: 'transient', ttl: { field: 'purge_after', expireAfter: '0h' } }. organization_id is an authored lookup to sys_organization, required: false, readonly: true, deleteBehavior: 'cascade' — the idiom sys_member, sys_invitation and sys_business_unit use. Census: reach: in, tenantField: organization_id, registered 93 → 94, in reach 47 → 48, out of reach unchanged. Inventory: fate: 'attribution', PMAIN, no anchors, the row shape of sys_exchange_rate above it. Q4 in the code on main: refuseOrganizationDeleteWhileOwned and organizationOwnedRowCounts skip outlivesOrganization (engine-owned) rows, so the entries are never counted against an organization delete; the walk reads the column through cascadeRelationBehavior as cascade, isTenantColumnSetNull is false for an authored cascade and keepsOrganizationReference applies only to set_null, so the organization's delete cascades into its entries through nested by-id deletes, each of a sys_ object, so none writes an entry of its own. Right — and unpinned: no test in the PR deletes an organization with entries (should-fix, a pin).
  4. Where the capture runs, and its unit of work. Inside runByIdDelete: open before the cascade (a retention defect throws here, before any write — pinned, rows and children intact); onCollected after collectCascadeDeleteSet and refuseRestrictsInsideOwnCascade, before walkReferencingRelations; write() after the walk and before driver.delete(root); compensate() when the root removal throws. buildDriverOptions attaches the explicit or ambient transaction only when transactionCoversDriverFor(object, tx) (the ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因) #5351 same-origin gate), so the capture's covered is true exactly when the bin's driver owns the handle; a bin on a datasource the transaction does not cover gets no handle and takes the compensation path, which is the right direction. Pinned: the entry carries the same handle as the root delete; a failed root removal rolls the entry back with the transaction; without one the entry is written and then removed (['create', 'delete']). Right.
  5. Failure direction against the plan ("the entry write and the delete still form one unit or the entry is compensated — no lost snapshot is silent"). A bin write that throws refuses the delete: in the atomic plan the transaction rolls the children back; in the none plan nothing references the object, so nothing was written before it; in the split plan (a cross-datasource cascade) the children are already gone when write() throws and the root stays, which is the pre-existing split posture (warnCascadeNotAtomic) with one more failure point in the same window. The never-collected path logs at error and lets the delete through (an engine defect, loud, as the plan asks). Holds, with the split note. The PR body's 「回收站表写失败时整个删除会被拒(不丢快照)」 is exact for atomic and none.
  6. Snapshot content. cascadeMembersInOrder is the root, then breadth-first along cascadeEdges (a master before its details), then any member no edge reached; recycleBinTakesObject drops sys_* members; declaredColumnSet keeps declared plus provisioned columns; deletedSecretColumns is collectSecretFields, so secret columns are left out (D4); undeclared columns are dropped (pinned). clearedReferences is assigned in onCollected and pushed after the update in the set_null branch, after both arities, with value: String(id); a nested delete of the same cascade walks with the inherited set, so its clears land on the root's list. Right.
  7. Attribution. deleted_by is context.userId, else context.attributedUserId — the audit ledger's own precedence (sess.userId ?? provenance.attributedUserId), pinned for both. organization_id is the root row's tenant column, else context.tenantId; the READERS row (row-value) says so. The inventory comment's "a NULL row has no producer" is not exact — a system-context delete of an object whose tenancy is disabled writes NULL — but fate: 'attribution' tolerates it (prose nit). Right.
  8. Retention. Resolver, then OS_RECYCLE_BIN_RETENTION_DAYS, then 30; both schemas and the refusal text are the spec's; the resolver's undefined falls through, its defect throws with 'resolver', the variable's with 'env'; purge_after is deleted_at plus whole days (26 cases). ObjectQLPlugin.init() reads the variable once, so a bad value stops the boot — stricter than the plan's "refused" and what the body declares; it also runs in a composition without the bin (nit). Right.
  9. The TTL sweep. '0h' parses (LIFECYCLE_DURATION_REGEX is ^(\d+)(h|d|w|y)$) to a zero window, so the cutoff is now and the selector is purge_after $lt now; an override can only lengthen, because the regex admits no negative and the declared window is already zero; no floor is registered. batchedReap deletes each due row by id through engine.delete, the object is sys_, so the reap writes no entry; nothing references the bin, so its plan is none. Pinned: due reaped, undue kept, the resolver asked zero times. Right — but see item 11 for what else that by-id delete does.
  10. Public surface. @objectstack/objectql gains the type RecycleBinRetentionSettings, ObjectQLPluginOptions.recycleBinRetention, ObjectQL.setRecycleBinRetention, and the optional CascadeDeleteSet.clearedReferences (the interface is not re-exported from index.ts); cascadeDeleteRelations gains a trailing optional parameter. @objectstack/platform-objects gains SysRecycleBin. All widening, covered by the minors. Right.
  11. Must-fix A — the reap of a bin entry is audited, with the snapshot in the ledger. The sweep deletes each due entry by id through engine.delete, so the engine dispatches afterDelete with the pre-image bound. plugin-audit's writeAudit is registered on afterDelete with excludeObjects: AUDIT_EXCLUDED_OBJECTS, and sys_recycle_bin is in neither that list nor SKIP_OBJECTS on main; the handler's only early returns are the skip set, a missing action and a missing api.sudo (the sweep's SYSTEM_CTX gets one from buildHookApi). So every reaped entry writes one sys_audit_log row and one sys_activity row whose old_value / metadata is the whole entry, snapshot included: every declared column of the deleted rows and their cascade, internal: true columns among them, which the ledger omits on the business row's own delete ([security] The compliance ledger stores a JWT signing-key row's key material in its create snapshot, and an admin is served it through the ledger's by-id door while the key object itself declares no API door #21197) and which the bin's own schema gives the ledger no reason to mask. The same handler fires on afterUpdate when a user's delete set_nulls deleted_by, and N times on an organization delete that cascades into N entries. Against: ADR-0057 §3.3's hard rule (the LifecycleService's own deletes are not audited or activity-logged, or cleanup re-feeds the tables it drains); the lifecycle header's own population invariant ("every lifecycle-declaring platform object is in the audit writer's SKIP_OBJECTS … and sys_file"); the object docblock's own claim ("no insert hook, audit mirror or activity row fires for the bookkeeping of a delete the audit already records"); ADR-0148 D4 (the snapshot is read only under the live object's FLS); and the sys_lifecycle_state lesson on feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751 (ruling 6663872259), where the generic writer picked up an engine-owned row the same way. The capture itself is clean: the entry is written raw, so the insert fires nothing. Minimal fix: one 'sys_recycle_bin' entry in SKIP_OBJECTS (group 2, beside sys_job_queue / sys_upload_session, with its reason), a pin in audit-writers.test.ts of feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751's shape (a delete and an update of a sys_recycle_bin row write no ledger row, a business delete still does), '@objectstack/plugin-audit': minor and one sentence in the changeset. The derived consequences are feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751's and are right for an object with no data API: read-audit.ts refuses a read-audit opt-in naming it, parent-record-read-gate.ts drops it from the enumerated parents. Whether a purge (PR 2, a human act) is audited is then an event-specific writer's question — ③.6.
  12. Must-fix B — a lifecycle reap of an object outside sys_ enters the bin. batchedReap issues engine.delete(object, { where: { id: row.id }, multi: true, context: SYSTEM_CTX }) per confirmed row, and the engine's ladder is keyed on the id (isByIdDelete = Boolean(id), beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574; not on options.multi, A compare-and-set where on a by-id update is silently inert — the extra predicate keys never reach the driver, and SqlHttpOutbox.redeliver's status guard is one of them #11009), so a reap is a by-id delete; openRecycleBinCapture sees only the object's name, so a lifecycle-declared object whose name is outside sys_ writes one entry per reaped row, stamped for 30 days, to be reaped again from the bin (and audited, item 11). Against: seat ruling Q6 in 6673429400 ("lifecycle reaps … do not create entries"), the plan, and the PR body and changeset ("a delete by predicate (multi: true), which includes the lifecycle sweep" does not enter the bin) — the release note declares a behaviour the code does not have. Reach today: every lifecycle-declaring *.object.ts in this tree, in hotcrm, in examples/ and in the templates is sys_ (17 files), so it is latent here; it is reached by any application or cloud_* object that declares lifecycle (ADR-0057's public authoring surface) and by the ai_traces telemetry object the audit writer already names. Minimal fix: in runByIdDelete, do not open the capture when the delete's own options.multi === true (the body's definition of a predicate delete), or hand openRecycleBinCapture that flag; one pin, a LifecycleService.sweep() over a lifecycle-declaring business object leaves the bin empty. The changeset sentence then becomes true as written.

② Semver level

  • Changeset .changeset/30237-recycle-bin-snapshot-on-delete.md: minor for @objectstack/objectql, @objectstack/platform-objects, @objectstack/spec, @objectstack/cli; no major (check-changeset-no-major.mjs, pre-GA). Level axis (ruling A as clause2-line.mjs states it): Metadata-protocol: added needs at least one package whose published source moves minor or above — spec (roster) and objectql do; the minor on cli for an inventory row is admissible under added, and all four sit in the one fixed group, so the per-package level touches the CHANGELOG heading and nothing of the version. After must-fix A the changeset gains '@objectstack/plugin-audit': minor, still consistent with added. Check Changeset concluded success on this head.
  • Clause-②: yes (widening) and Metadata-protocol: added are byte-identical in the PR body and the changeset, and match the claim and the consent's terms. Part of #30237 is in the body (PR 1 of 5, so no Fixes).
  • Skills-docs: grammar follow-up owed — FILES is the declared one in both, naming the same file and lines; the parentheticals differ ("they teach that there is no recycle bin" in the body, "they still teach that there is no recycle bin; not moved in this release" in the changeset), so the two lines are not byte-identical. No script reads the line (clause2-line.mjs and check-changeset-no-major.mjs read only the two lines above it); the seat rewrites owed — FILES to #N at ACCEPT anyway. Should-fix, prose.

③ Boundary flags

open_questions: [] in the dev report. The declared deviation and the body's acceptance notes, each answered:

  1. Builder spelling, restated bounds, literal name (check:keyed-text-bounds). The gate is a source scan by design (its header: a central pin cannot import the plugins' objects without a cycle); it refuses, rather than skips, a fields entry it cannot attribute to a column (a spread) and reads a keyed text column's maxLength only as an integer literal — and object_name / record_id are keyed by the first declared index, so their bound has to be visible in source. The spec's own bound on both is 255, so the restatement is equal by value and the plugin.test.ts pin keeps it so (the 254 ablation reds). The literal name carries its constant in a trailing comment and the pin asserts equality with RECYCLE_BIN_OBJECT. Sound; the one honest way to satisfy the gate without weakening it.
  2. Census row in a later commit than the consent's same-commit term — hunk (a) above. Accepted, named.
  3. File surface against amendment 6673429400. packages/objectql/src/index.ts (one type re-export) and federated-injected-column-readers.test.ts (a READERS row the resolveTenantFieldName() use trips) are not named by path; both sit inside the claim's packages/objectql/src/** and the amendment's "rows the new object trips". Noted.
  4. Boot-time read of the retention variable. Stricter than the plan's "refused" at delete time, declared in the body and the changeset; it also stops a composition that registers no bin. Accepted, nit.
  5. No translations bundle. platform-objects/scripts/i18n-extract.config.ts enumerates its objects by hand and does not list SysTrustedKey either, so check:i18n derives nothing for sys_recycle_bin and is green; the labels reach no list page until PR 5 / ui: the admin recycle-bin page (list, restore, empty) and the 已删除 · 撤销 toast (#3146 slice 4) #30239, which should add the object to the config and regenerate. Noted.
  6. Open question, does not block (policy for the maintainer, with PR 2): once sys_recycle_bin is in SKIP_OBJECTS (must-fix A), no generic ledger row records anything done to an entry; a purge or an empty by an administrator is a human act, so if the maintainer wants it in sys_audit_log, PR 2 owes an event-specific writer (the action-invocation-audit.ts shape), not the generic one. The seat should name it when announcing PR 2.
  7. Open question, does not block (seat ruling Q6, with must-fix B): Q6 reads "business object" as "a name outside sys_"; the census's own platform namespace is sys_ / cloud_ / ai_. Should recycleBinTakesObject exclude every platform prefix, so a cloud_* or ai_* row never enters a tenant's bin? One line either way; the seat decides and the release note follows.
  8. PR body prose (the seat's stroke at ACCEPT, os-dev rule 1 bars a dev PATCH): the "same commit" sentence (hunk (a)); the "Skills-docs" parenthetical (②).

Author note: PR user.login is objectstack-fleet[bot]; no outside author.

④ Design judgment

  1. Hunk (a) against its consumers. The roster test and the declaration land together; the inventory test is satisfied by the row; the census gate ran green in "Lint · repo & tooling gates" on this head; no generated artifact derives from the roster. Holds.
  2. Hunk (b) against the tree. The object file is where the comment says, in that package; it builds fields from RECYCLE_BIN_ENTRY_FIELDS (through builders, which is still "from"); PlatformObjectsPlugin registers it; the roster lists it. The sentence the consent asked to keep still explains the order. Holds.
  3. The object against ADR-0148 D1 / D3 / D4 and ruling Q4. One entry per delete operation; the deadline stamped per entry and read by the sweep without a resolver, so a later shortening never shortens a window shown; no read path filters on it; no generic data API, so the snapshot is read only by the doors PR 4 and PR 5 open under the live object's FLS; the organization cascade. Holds on the write side; item 11 is the one place the snapshot leaves the bin before those doors exist, through the generic ledger on reap — a D4 breach by the audit copy, fixed by must-fix A.
  4. Pins are real. The dev's ablation of openRecycleBinCapture(...) reds 11 of 16, and the five that stay green assert absence or the BFS helper; the 254 ablation reds the field pin. Reading the suites: the driver keeps real rows and a real per-transaction snapshot, so "rolled back with the transaction" and "compensated without one" are told apart by what is in the store and the order of writes, not by a stub's say-so.
  5. Tried to break it. A restrict inside the cascade: refuseRestrictsInsideOwnCascade runs before onCollected, nothing read, nothing written. A beforeDelete refusal or a permission refusal: before runByIdDelete, no capture. A resolver that throws rather than answers: propagates out of openRecycleBinCapture inside the unit, the delete is refused before any write (same direction as the pinned defect, unpinned). A delete inside a caller's explicit transaction: buildDriverOptions prefers the explicit handle, covered is true, the caller's rollback carries the entry. A driver answering no id on create: compensate logs at error with the record named, the delete's own error still wins. A root with no tenant column deleted by a user in organization A: the entry takes A from the context; by a platform administrator with no active organization: a NULL row, PR 4's listing has to say whose it is. 600 cascade members: two 500-id reads, one entry of 601 rows — no cap on an entry's size; a ten-thousand-row cascade is one JSON cell, a later card's question. The same record id deleted, re-created and deleted again: two entries under the (object_name, record_id) index, newest first. A user deleted after deleting records: deleted_by is cleared by the walk's set_null through engine.update under the referential marker — and audited, item 11. An organization deleted with entries: the cascade removes them, each a sys_ delete writing no entry — and each audited, item 11. A reap of a business object with a lifecycle block: item 12. A cloud_* row deleted by id: enters the bin — ③.7. A predicate delete with a scalar id and multi: true from a flow's delete_record: today enters the bin; after must-fix B it does not, which is what the body already says.

Should-fix (non-blocking): (a) a pin for Q4 (an organization delete removes its entries and is not refused by them); (b) the Skills-docs: parentheticals (②); (c) the PR body's "same commit" sentence (hunk (a)); (d) the inventory comment's "a NULL row has no producer"; (e) the i18n extract config entry, with PR 5 / #30239; (f) the entry-size question, a later card.

Implemented-by: claude/project-thread-jf62r6
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: FAIL

…and the bin's own bookkeeping out of the recycle bin and the ledger

The lifecycle sweep reaps each row with `where: { id }, multi: true`, which
the engine routes as a by-id delete, so a business object that declares a
lifecycle would have entered the bin on every reap. The capture now opens
only when the caller did not ask for `multi: true`. The bin also leaves out
every platform namespace (sys_, cloud_, ai_), not only sys_.

plugin-audit's generic writer would have copied each entry, snapshot and
internal columns included, into sys_audit_log and sys_activity on its reap,
on the set_null of its deleted_by and on an organization delete's cascade.
sys_recycle_bin joins SKIP_OBJECTS beside the other engine-owned plumbing.

Pins: a predicate-shaped by-id delete and a lifecycle sweep of a business
object write no entry; cloud_ and ai_ objects write none; an organization
delete removes its entries without being refused by them; a reap and an
update of an entry write no ledger row while a business delete still does.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a07be615a15c79843c7ec65e7e0592cb0503d1b
Local-runs: none
Scope: packages/spec/src/data/recycle-bin.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/platform-objects/src/system/sys-recycle-bin.object.ts, packages/platform-objects/src/system/index.ts, packages/platform-objects/src/plugin.ts, packages/platform-objects/src/plugin.test.ts, packages/objectql/src/recycle-bin/capture.ts, packages/objectql/src/recycle-bin/capture.test.ts, packages/objectql/src/recycle-bin/retention.ts, packages/objectql/src/recycle-bin/retention.test.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-cascade-planning.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/plugin.ts, packages/objectql/src/index.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/plugins/plugin-audit/src/audit-writers.ts, packages/plugins/plugin-audit/src/audit-writers.test.ts, packages/cli/src/utils/organization-ownership-inventory.ts, scripts/platform-object-tenancy-census.json, scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json, .changeset/30237-recycle-bin-snapshot-on-delete.md (widened to the whole non-prose diff; the cited code on main — lifecycle/lifecycle-service.ts batchedReap and its per-row reap, engine.ts delete() ladder (isByIdDelete, isPredicatePath) and runByIdDelete, engine-cascade-planning.ts refuseOrganizationDeleteWhileOwned / outlivesOrganization / isTenantColumnSetNull, plugin-audit audit-writers.ts SKIP_OBJECTS / AUDIT_EXCLUDED_OBJECTS / writeAudit, read-audit.ts, spec/system/constants/platform-object-names.ts PLATFORM_OBJECT_PREFIXES / hasPlatformObjectPrefix, spec/system/index.ts and constants/index.ts (the export path), scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, docs/adr/0057 §3.3 as #30751's record cites it — was read, not reviewed)

Re-record at the new head. The prior record at 819b02f7 (PR comment 6675639559) was a FAIL with two must-fixes (A: the generic audit writer ledgers the bin's bookkeeping; B: a lifecycle reap of a non-sys_ object enters the bin) and should-fixes (a) to (f). The seat ordered the fixes in claim amendment 6675653517 on #30237, which also widened the PR 1 file surface by the two plugin-audit files and '@objectstack/plugin-audit': minor, and ruled the review's open question ③.7: a business object is a name outside every platform namespace the tenancy census uses, sys_, cloud_ and ai_. Governing text otherwise unchanged: card #30237 body; unblock 6673225435; claim 6673233448 and amendments 6673429400 and 6675653517; plan-30237.md; spec#3 hand-over 6665087359 and consent 6673450505 on #30658; dev report 6675407579; ADR-0148 D1–D4; the PR body as re-patched. Read-only shape held: git fetch origin claude/project-thread-jf62r6 main answers 6a07be61 as the head, one commit on top of 819b02f7 (git diff 819b02f7 6a07be61: 8 files, +174 −24) and six commits on main in all (git diff origin/main...6a07be61: 22 files, +1460 −20, matching the PR's changed_files 22 / 1460 / 20); files via git show; the card and every comment; the head's check-suites and check-runs through the API. PR user.login is objectstack-fleet[bot], head repo is the base repo, the PR is a draft, no auto-merge armed.

Check-runs on this head (API, read at 2026-11-06T03:52Z): check-runs answers total_count: 0; check-suites lists 5 suites (objectstack-fleet, claude, vercel, fly-io, cloudflare-workers-and-pages), every one queued with no conclusion; the combined status is pending with 0 statuses; actions/runs?head_sha= answers 0 runs. So no job has started on 6a07be61 at review time: nothing is green and nothing is red. This record does not wait on CI (the thread's own contract), and no judgment below is one a shard would contradict; the landing still needs every check green, which the owning seat reads before it queues.

Each ordered fix, verified against the diff.

  1. Must-fix A, resolved. audit-writers.ts SKIP_OBJECTS gains 'sys_recycle_bin' in group 2, between sys_upload_session and ai_traces, with a twelve-line reason naming ADR-0148 D4 and ADR-0057 §3.3, the three bookkeeping writes (the reap, the set_null of deleted_by, the organization cascade), what the generic copy would carry (snapshot, internal: true columns) and where the snapshot is read instead. AUDIT_EXCLUDED_OBJECTS spreads the set, so the hook registration's excludeObjects and the in-handler SKIP_OBJECTS.has(ctx.object) early return both cover it; read-audit.ts derives from the same set, as the prior record said. audit-writers.test.ts gains a describe of three cases on the file's own makeEngine / fire harness: afterDelete of a sys_recycle_bin row under a system session writes nothing; afterUpdate clearing deleted_by under a user session writes nothing; afterDelete of crm_lead still writes ['sys_audit_log', 'sys_activity']. The harness fires registered handlers directly, so the pin exercises the in-handler set, which is the one that matters when a host registers the writer without the exclude list. The dev's ablation (the entry removed) reds 2 of 3 and keeps the business control green, which is the right split. The lifecycle header's population invariant (every lifecycle-declaring platform object is in SKIP_OBJECTS) now holds for the bin, and the object docblock's claim is restated to say which layer keeps the later writes out of the ledger.
  2. Must-fix B, resolved. runByIdDelete passes options?.multi === true as a fourth argument predicate; ObjectQL.openRecycleBinCapture (private) forwards it; openRecycleBinCapture in capture.ts returns undefined on predicate || !recycleBinTakesObject(object) before any read. The sweep's per-row reap is engine.delete(object, { where: { id: row.id }, multi: true, context: SYSTEM_CTX }) (lifecycle-service.ts), the ladder keys on Boolean(id) so it arrives in runByIdDelete, and options there is the delete's own argument, so the flag is the reap's. Two pins: a by-id delete of loner with multi: true removes the row and writes no entry; a real LifecycleService (getEngine, fixed now) sweep() over ephemeral, a non-platform object declaring lifecycle: { class: 'transient', ttl: { field: 'expires_at', expireAfter: '0h' } }, reports no errors, reaps the due row, keeps the undue one and leaves the bin empty. The dev's ablation (the flag forced to false) reds exactly those two. The changeset's sentence now reads "which includes every lifecycle sweep reap, even though the sweep names each reaped row by its id", which is what the code does. Side effect, declared in the body: a flow's delete_record that passes a scalar id beside multi: true also skips the bin, which is the body's definition of a predicate delete.
  3. Ruling ③.7 (prefixes), implemented. recycleBinTakesObject is !hasPlatformObjectPrefix(object), imported from @objectstack/spec/system (system/index.ts exports ./constants, which exports ./platform-object-names; objectql already imports that subpath in engine-cascade-planning.ts, so no new package edge). PLATFORM_OBJECT_PREFIXES on main is ['sys_', 'cloud_', 'ai_'], the census's population, so the capture's reach and the census's platform set are one list. Pinned with it.each(['sys_thing', 'cloud_thing', 'ai_thing']); the dev's ablation back to sys_ reds cloud_thing and ai_thing. The capture docblock, the function's TSDoc, the changeset and the body all say the three prefixes.
  4. Should-fix (a), Q4 pin, resolved. sys_organization is registered in the harness; two loner rows are deleted under contexts with tenantId org_1 and org_2, giving two entries stamped from the context (loner has no tenant column); the by-id delete of org_1 resolves truthy, leaves org_2 in sys_organization, and leaves exactly the org_2 entry. That is the ruling: removed with the organization, not refused by it, the other organization's untouched.
  5. Should-fix (b), resolved. The changeset's Skills-docs: line is now byte-identical to the body's (follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin)).
  6. Should-fix (c), resolved in the body. The spec paragraph now says the roster line shares d374cbe5 with the object file, its registration and the ownership-inventory row, and that the census row followed in 819b02f7 after its gate reported the drift, which is what the commit API shows (hunk (a) of the prior record).
  7. Should-fix (d), resolved. The inventory comment names the one NULL producer: a tenancy-disabled object deleted by a context with no active organization, and says a NULL row comes only from that.
  8. Should-fix (e) and (f), carried, not owed here. (e) the i18n-extract.config.ts entry rides PR 5 / ui: the admin recycle-bin page (list, restore, empty) and the 已删除 · 撤销 toast (#3146 slice 4) #30239 as the prior record said; (f) the entry-size question is a later card. Neither was ordered for this head.

The two packages/spec hunks against the consent: unchanged since 819b02f7. git diff 819b02f7 6a07be61 touches nothing under packages/spec; hunk (a) ('sys_recycle_bin' in PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'], +1) and hunk (b) (the four // lines above RECYCLE_BIN_OBJECT, +4 −4, present tense, module header byte-identical to main) stand as judged in the prior record, and the diff's file list under packages/spec is exactly those two paths. Nothing to regenerate. Holds.

File surface against the claim as amended. The new commit touches .changeset/…, cli/…/organization-ownership-inventory.ts, objectql/src/engine.ts, objectql/src/recycle-bin/capture.ts and capture.test.ts, platform-objects/…/sys-recycle-bin.object.ts (docblock), plugin-audit/src/audit-writers.ts and audit-writers.test.ts: every path is inside 6673429400 (packages/objectql/src/**, the object file, the inventory row, changesets) or 6675653517 (the two plugin-audit files). The whole-PR list is the prior record's 20 paths plus those two. ui/**, skills/**, docs/adr/** and the governed register untouched. Met.

① Derived judgments

The prior record's items 1–10 stand unchanged for the code they judged (roster widening; the object mirrors RECYCLE_BIN_ENTRY_FIELDS through builders with the field pin; posture and tenancy; the capture's placement in the unit of work and its covered / compensation directions; the failure direction per plan; snapshot content and clearedReferences; attribution; retention; the '0h' TTL sweep; the public surface). What this head changes in them:

  1. Reach (items 6, 9, 12 of the prior record). The bin now takes a by-id delete that is the root of its cascade, of a non-platform object, asked without multi: true. The three exclusions compose in openRecycleBinCapture in that order (flag, prefix, cascade membership) and each is pinned separately. A platform row the cascade reaches is still dropped from the snapshot by the same predicate (cascadeMembersInOrder filter), so a cloud_* detail of a business master is now left out where before only sys_* was; consistent with the ruling, and the release note says so.
  2. The ledger (item 11 of the prior record). With the bin in SKIP_OBJECTS, the reap, the deleted_by clear and the organization cascade write no sys_audit_log / sys_activity row; the business delete an entry was taken for is audited as on main. D4's "read only under the live object's FLS" holds on every path the generic writer could reach. The one remaining ledger question (whether a human purge is recorded) is PR 2's, flagged below.
  3. Signature change is internal. openRecycleBinCapture gains a required parameter; recycle-bin/capture.ts is not re-exported from index.ts (only the RecycleBinRetentionSettings type is) and the engine member is private, so no published surface moves for it. The public additions are the prior record's and all widening.
  4. Pins are real. The sweep pin boots a real LifecycleService over a real ObjectQL and asserts the due row gone, the undue row kept and the bin empty, so a regression that re-opens the capture for reaps writes an entry the assertion sees; the multi: true pin asserts both the row's removal and the bin's emptiness; the Q4 pin asserts the surviving organization and its surviving entry, not only a count.

② Semver level

  • Changeset .changeset/30237-recycle-bin-snapshot-on-delete.md: minor for @objectstack/objectql, @objectstack/platform-objects, @objectstack/spec, @objectstack/cli and now @objectstack/plugin-audit; no major (check-changeset-no-major.mjs, pre-GA). Metadata-protocol: added is unchanged: a new metadata object and the roster entry are what an author can now write, and the kind takes at least minor on every package the diff moves (Post-Task Checklist step 3), so plugin-audit: minor is the rule's own answer, not a judgment call; it is also a published behaviour change in that package (one more object the ledger never records). All five sit in the one fixed group, so the per-package level touches the CHANGELOG heading and nothing of the version. The changeset carries its one new sentence for plugin-audit, matching the amendment's order.
  • Clause-②: yes (widening) re-judged: the roster answers true for one more real object; the new exclusions narrow what the bin takes relative to 819b02f7, not relative to any published contract on main, where the bin does not exist; the audit skip withholds ledger rows only for an object main has no rows of. Nothing a consumer could accept, write or read on main is refused or removed. Widening, as declared. The two lines are byte-identical in the PR body and the changeset and match the claim and the consent. Part of #30237 in the body (PR 1 of 5, no Fixes).
  • Skills-docs: lines byte-identical (should-fix (b) above).

③ Boundary flags

open_questions: [] in the dev report; the body declares no new deviation. Carried from the prior record, re-read at this head:

  1. Builder spelling, restated 255 bounds, literal name (check:keyed-text-bounds): unchanged, sound, pinned. Accepted.
  2. Census row three commits after the roster line: unchanged; the body now says so. Accepted, named.
  3. Two objectql paths not named by path in 6673429400 (index.ts, federated-injected-column-readers.test.ts): inside the claim's packages/objectql/src/**. Noted.
  4. Boot-time read of OS_RECYCLE_BIN_RETENTION_DAYS in ObjectQLPlugin.init(): stricter than the plan's "refused" and declared in body and changeset; it also stops a composition that registers no bin. Accepted, nit.
  5. No translations bundle for sys_recycle_bin (i18n-extract.config.ts enumerates by hand): rides PR 5 / ui: the admin recycle-bin page (list, restore, empty) and the 已删除 · 撤销 toast (#3146 slice 4) #30239. Noted.
  6. For the seat to name when announcing PR 2 (does not block): with the bin in SKIP_OBJECTS, no generic ledger row records a purge or an empty by an administrator. If the maintainer wants a human purge in sys_audit_log, PR 2 owes an event-specific writer (the action-invocation-audit.ts shape); the SKIP_OBJECTS reason already says as much.
  7. For the seat, a reading to confirm, not a ruling (does not block): a flow delete_record or an API caller that sends { where: { id }, multi: true } for one row now skips the bin by the caller's flag. The body states it as the definition of a predicate delete; if the seat wants the product behaviour to key on the ladder's verdict instead (so a multi: true spelling of one id still enters the bin), the lifecycle reap would then need its own marker, and that is a design change outside this PR's order. As ordered, the flag is the right discriminator.
  8. CI on this head has not started (0 check-runs, 5 suites queued at review time). The landing reads every check green before the queue; this record does not.

Author note: PR user.login is objectstack-fleet[bot]; head repo is the base repo; no outside author.

④ Design judgment

  1. Hunk (a) against its consumers: unchanged since the prior record; the roster test and the declaration land together; nothing generated derives from the roster. Holds.
  2. Hunk (b) against the tree: unchanged; the object file is where the comment says, built from RECYCLE_BIN_ENTRY_FIELDS through builders, registered, listed. Holds.
  3. The object against ADR-0148 D1 / D3 / D4 and ruling Q4, with the ledger closed. One entry per delete; the deadline stamped per entry and read by the sweep without a resolver; no generic data API; the organization cascade pinned; and now no path on which the snapshot leaves the bin before PR 4 / PR 5's doors exist, since the generic writer is the only other reader of a bin row's pre-image and it is skipped. Holds on the write side.
  4. The reach against ADR-0057 §3.3 and ruling Q6 as extended. A lifecycle reap of any object, platform or business, writes no entry (the flag) and no ledger row for the bin's own reap (the skip); a declared-lifecycle business object is reaped exactly as on main. The sweep's by-id spelling (per-row reap with its own pre-image, hooks and walk, finding(objectql): LifecycleService 的无 guard reap 每次 sweep 发一条无上限 DELETE —— 首次给存量大表加 retention 时是一条长事务 #5194) is untouched, so the reap's existing guarantees stand.
  5. Tried to break it at this head. A by-id delete with multi: false or no flag: enters the bin (the 16 original pins). A by-id delete with multi: true that cascades into business details: the root opens no capture, the nested child deletes are cascadeIssuedDeletes and inherit the cascade set, so no child opens one either; no partial entry. A cloud_* master with business details: no entry, and its details are deleted as on main (the ruling's cost, named in the changeset as "any platform object"). A business master with a cloud_* detail: entry written, the detail's rows left out of snapshot (prefix filter), its set_null clears still recorded on clearedReferences since that branch does not filter by prefix; a later restore re-links a platform row it never held, which is a PR 3 reading. The reap of an entry: sys_, no capture; SKIP_OBJECTS, no ledger row; afterDelete still dispatched, so a host's own hook on the bin still fires, which is right. A user deleted after deleting records: the walk's set_null on deleted_by goes through engine.update under the referential marker, no ledger row now. An organization deleted with entries in two organizations: pinned.

Should-fix (non-blocking): (e) the i18n extract config entry, with PR 5 / #30239; (f) the entry-size question, a later card; (g) ③.6 named in PR 2's announcement.

Implemented-by: claude/project-thread-jf62r6
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS


Generated by Claude Code

…-jf62r6

# Conflicts:
#	packages/platform-objects/src/plugin.test.ts
#	packages/platform-objects/src/plugin.ts
#	packages/platform-objects/src/system/index.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 19d3f2a14a904d92a2cb33940a989c10156273e5
Local-runs: none
Scope: packages/spec/src/data/recycle-bin.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/platform-objects/src/system/sys-recycle-bin.object.ts, packages/platform-objects/src/system/index.ts, packages/platform-objects/src/plugin.ts, packages/platform-objects/src/plugin.test.ts, packages/objectql/src/recycle-bin/capture.ts, packages/objectql/src/recycle-bin/capture.test.ts, packages/objectql/src/recycle-bin/retention.ts, packages/objectql/src/recycle-bin/retention.test.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-cascade-planning.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/plugin.ts, packages/objectql/src/index.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/plugins/plugin-audit/src/audit-writers.ts, packages/plugins/plugin-audit/src/audit-writers.test.ts, packages/cli/src/utils/organization-ownership-inventory.ts, scripts/platform-object-tenancy-census.json, scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json, .changeset/30237-recycle-bin-snapshot-on-delete.md (widened to the whole non-prose diff; the cited code on main at 43ae2843 — packages/platform-objects/src/system/sys-lifecycle-state.object.ts and the sys_lifecycle_state entries #30751 (b5eb61df) left in the same eight files, scripts/regen-artifacts.mjs / .gitattributes (the merge=os-regen routing of the census) — was read, not reviewed)

Re-record at a merge head. 19d3f2a1 is a merge commit with parents 6a07be61 (the head this seat passed in PR comment 6676396856) and 43ae2843 (origin/main when the thread merged it); it resolves the conflict with #30751, which landed sys_lifecycle_state in the same platform-objects files. The PASS at 6a07be61 is the baseline for everything the merge leaves alone; what follows is the merge, judged on its own. Governing text unchanged: card #30237 body and every comment through amendment 6675653517; plan-30237.md; consent 6673450505 on #30658; dev report 6675407579; the PR body. Read-only shape held: git fetch origin claude/project-thread-jf62r6 main answers 19d3f2a1 as the head; git merge-base 19d3f2a1 43ae2843 is 43ae2843, so the PR's base is that commit (the API's base.sha agrees; main has since moved on to 172eebb2, which is not in this diff); git diff 43ae2843 19d3f2a1 and git diff 254f2ec1 6a07be61 (the reviewed delta, 254f2ec1 being git merge-base 6a07be61 43ae2843) compared file by file; git merge-tree --write-tree 6a07be61 43ae2843 for the conflict set; files via git show; the head's check-runs through the API. PR user.login is objectstack-fleet[bot], head repo is the base repo, draft, no auto-merge; the API's changed_files / additions / deletions are 22 / 1460 / 20, as at 6a07be61.

Check-runs on this head (API, read at 2026-11-06T04:10Z): 41 runs, 20 completed (12 success, 8 skipped), 21 in_progress, 0 red. Green: Governed Surface Queue Guard, PR-body guards, Check Changeset, Check PR Size, Auto Label, Flag docs, Spec property liveness, Plan Test Core shards, Dogfood Regression Gate, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, filter. Running: Lint · ESLint, Lint · repo & tooling gates, Lint · CI release & engine gates, Lint · docs & code guards, Lint · scoped (two), Type Check · source / consumer / debt ledger / workspace, Build Core, Build UI Deps, Live E2E UI, Test Core 1/8 to 8/8. Running CI does not decide this record; the landing reads every check green first.

The four things ordered, each verified.

  1. Outside the conflict files, the PR's lines against main equal the reviewed delta. git diff --numstat of the two deltas sorts to the same 22 rows, insertion and deletion counts equal per file (1460 / 20 in all). Patch text with index, diff --git, --- and +++ lines stripped is byte-identical for 14 files: the changeset, engine-cascade-planning.ts, engine-top-level.ts, engine.ts, federated-injected-column-readers.test.ts, index.ts, objectql/plugin.ts, capture.ts, capture.test.ts, retention.ts, retention.test.ts, sys-recycle-bin.object.ts, recycle-bin.zod.ts, the ADR anchor. The other eight are exactly the eight files feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751 (b5eb61df) also moved between the two bases, and in six of them the added and removed lines are identical to the reviewed delta and only context moved: organization-ownership-inventory.ts, system/index.ts, audit-writers.ts, audit-writers.test.ts, platform-object-names.ts (the 'sys_recycle_bin' line, still between 'sys_platform_setting_audit' and 'sys_scim_connection_binding'), and the census's row. In the remaining two, platform-objects/plugin.ts and plugin.test.ts, the added and removed lines differ from the reviewed delta by one token each, SysLifecycleState / 'sys_lifecycle_state' now present on both sides of the line the PR extends, plus "six" becoming "seven" where the reviewed delta had "five" becoming "six". That is item 3.
  2. The packages/spec hunks are unchanged. recycle-bin.zod.ts: patch byte-identical (+4 −4, the four // lines, module header untouched). platform-object-names.ts: the one added line is identical and at the same sorted position; main's own line for sys_lifecycle_state sits elsewhere in the roster and auto-merged. Nothing else under packages/spec in the diff. The consent's terms hold as at 6a07be61.
  3. The hand resolutions keep both sides and invent nothing. git merge-tree --write-tree 6a07be61 43ae2843 conflicts in exactly three files: platform-objects/src/plugin.ts, src/system/index.ts, src/plugin.test.ts; it auto-merges the inventory, both plugin-audit files, the roster and (textually) the census, and flags the census as merge=os-regen, to be regenerated. git diff of the merge-tree result against the head touches only those three files plus the census, so every auto-merged file in the head is byte-equal to what git produced. Each conflict hunk against its resolution:
    • plugin.ts, three hunks: the import (ours SysRecycleBin, theirs SysLifecycleState) → both imports, SysLifecycleState then SysRecycleBin; the docblock bullet (ours sys_recycle_bin, theirs sys_lifecycle_state) → both bullets in that order, each byte-identical to its side; the manifest objects list → [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysLifecycleState, SysRecycleBin], main's list plus the PR's one member at the end.
    • system/index.ts, one hunk: both exports with both comment lines, SysLifecycleState then SysRecycleBin, each line byte-identical to its side.
    • plugin.test.ts, four hunks: the import → both names in the one alphabetical list; the it title → "SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysLifecycleState and SysRecycleBin"; the toEqual list → the same seven as plugin.ts; the names list → 'sys_lifecycle_state' with its two-line comment, then 'sys_recycle_bin' with its three-line comment, both blocks byte-identical to their sides; and the count sentence, outside any marker, "six above" → "seven above", which is the only line neither side wrote and is the count of the list it describes. The PR's own second case (the field pin) is untouched by the merge.
      No line from either side is dropped, no ordering other than lifecycle-before-bin is introduced, and the resulting objects list, toEqual list and names list agree with each other.
  4. The census adds only this PR's row on top of main's. main's own move (254f2ec1 → 43ae2843) added the sys_lifecycle_state row (reach: out, systemFields.tenant: false), registered 93 → 94, outOfReach 46 → 47, that reason's total 20 → 21. The head against 43ae2843 adds exactly the sys_recycle_bin row (reach: in, tenantField: organization_id, in its sorted place before sys_scim_connection_binding), registered 94 → 95, inReach 47 → 48, outOfReach and reasonTotals unchanged. That is what a regeneration from the merged tree produces, and Lint · repo & tooling gates, which carries check:platform-object-tenancy-census, is among the runs still in progress.

① Derived judgments

Items 1–4 of 6676396856 and, through it, items 1–10 of 6675639559 stand: the merge changes no line of the capture, the retention, the engine's delete branch, the cascade planning, the object file, the audit skip or the pins. What the merge adds to the reading:

  1. sys_lifecycle_state beside sys_recycle_bin. Both are engine-owned, both lifecycle-related, both in plugin-audit SKIP_OBJECTS (the bin's entry sits before ai_traces, main's lifecycle-state entry after it; the set is unordered). The lifecycle header's population invariant now names two platform objects that declare or serve a lifecycle and both are skipped. The bin's own lifecycle block is swept by the same LifecycleService that now keeps a watermark in sys_lifecycle_state; the watermark changes when a sweep runs, not what a sweep deletes, so the '0h' TTL reading and the sweep pin (capture.test.ts runs its own LifecycleService with a fixed now) are unaffected. The sweep pin's harness registers no sys_lifecycle_state object; feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751's service treats a kernel without it as one that cannot say and sweeps as before, so the pin's sweep() still reaps.
  2. Registration order. The manifest registers the bin last, after SysLifecycleState; nothing keys on the position (the test's three lists agree, and SchemaRegistry ownership is by name).
  3. Public surface unchanged from 6a07be61.

② Semver level

Unchanged: the changeset is byte-identical to 6a07be61's (five packages at minor, no major, Clause-②: yes (widening), Metadata-protocol: added, Skills-docs: identical to the body's). The merge adds no published change of its own; #30751's changeset is already on main. Check Changeset concluded success on this head.

③ Boundary flags

Carried from 6676396856, none changed by the merge: ③.1 builder spelling and restated bounds (accepted); ③.2 census row commit lag (accepted, named in the body); ③.3 two objectql paths inside the claim's glob (noted); ③.4 boot-time env read (nit); ③.5 no i18n bundle, PR 5 / #30239 (noted); ③.6 a human purge's ledger row is PR 2's event-specific writer (for the seat to name with PR 2); ③.7 the { where: { id }, multi: true } reading (as ordered, the flag is the discriminator). New at this head:

  1. PR body prose, the seat's stroke at ACCEPT (os-dev rule 1 bars a dev PATCH): the Verification section still opens "Head 6a07be61" and reports gates on that head; the merge head is 19d3f2a1. The dev's local gate round was on 6a07be61, which is true as written; a sentence naming the merge and that the conflict files were re-run, or the seat's own reading of CI on 19d3f2a1, is what the body owes. Should-fix, prose.
  2. CI in progress (21 of 41 at review time, 0 red). The landing reads every check green before the queue; this record does not. The platform-objects plugin.test.ts now asserts seven objects in three places, and Test Core is the shard that proves the resolution compiles and agrees with plugin.ts; it is running.

Author note: PR user.login is objectstack-fleet[bot]; head repo is the base repo; the merge commit's author and committer are Claude, its message is git's default with the three conflict paths listed and carries no Claude-Session trailer (a merge commit, not a hand-written one; noted, not a finding); no outside author.

④ Design judgment

  1. Hunk (a) against its consumers: the roster now lists both new objects; platform-object-names.test.ts on main holds the roster to the declared *.object.ts set both ways, and both declarations are in the merged tree. Holds.
  2. Hunk (b) against the tree: unchanged. Holds.
  3. The resolution against what each side meant. feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751 added a tenant-less, engine-owned clock row and registered it with the engine-facing infrastructure; this PR adds a tenant-scoped, engine-owned snapshot table and registers it the same way. The merged plugin.ts docblock keeps both rationales in full, the manifest carries both, the barrel exports both, the test pins both, the census counts both (one in reach, one out), the roster names both, the audit writer skips both. No invariant either side pinned is weakened by the other's presence. Holds.
  4. Tried to break it at the merge. A boot that registers the bin and the lifecycle state together: two distinct names, two files, no shared index or table, no double registration. A sweep on a store with a fresh watermark: skips the sweep the interval has not come due for, so a bin entry past purge_after waits at most one interval longer than before, which is feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751's documented behaviour for every lifecycle object and not a change the bin introduces. A reap of sys_lifecycle_state rows: sys_, no capture; skipped by the writer. The merge-tree's auto-merge of audit-writers.test.ts: feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751's describe and this PR's describe are separate blocks, both present, neither edited.

Should-fix (non-blocking): (e) the i18n extract config entry, with PR 5 / #30239; (f) the entry-size question, a later card; (g) ③.6 named in PR 2's announcement; (h) the body's Verification head line (③.8).

Implemented-by: claude/project-thread-jf62r6
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT · 2026-11-06T04:38Z · #30237 (PR 1 of 5: object, snapshot on delete, retention stamp, TTL reap)

Head-sha: 19d3f2a

  • Shape: 22 files, +1460/−20, inside claim 6673233448 and amendments 6673429400 and 6675653517. The surface:

    • packages/objectql recycle-bin capture and retention, wired into the by-id delete and the plugin;
    • the sys_recycle_bin object in packages/platform-objects;
    • two consented packages/spec hunks (data/recycle-bin.zod.ts and the platform-object roster line; consent 6673450505 on pm:mailbox — domain:server#1 · os-elon-musk #30658);
    • plugin-audit SKIP_OBJECTS, the ownership inventory row, the tenancy census, one ADR anchor file and one changeset.

    Not governed. The head is a merge of origin/main 43ae2843, with a merge commit and no rebase, resolving the conflict with feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751's sys_lifecycle_state.

  • Contract review: PASS at CONTRACT_REVIEW_TIER, record 6676650056 at this head, with no must-fix. It chains from PASS 6676396856 at 6a07be61, which cleared FAIL 6675639559 at 819b02f7:

    • Must-fix A: the bin's bookkeeping writes no audit ledger row.
    • Must-fix B: a lifecycle reap or a by-id multi: true delete never enters the bin.
    • Ruling ③.7: every platform prefix is excluded (sys_, cloud_, ai_).

    The merge keeps both sides in the three hand-resolved platform-objects files and invents nothing. The census adds only this PR's row.

  • Gates: every check-run on this head has concluded: 55 success, 9 skipped, 0 failure. The card thread also reran on the merged head: the platform-objects, plugin-audit and objectql suites, the spec roster test, check:platform-object-tenancy-census and check:keyed-text-bounds, all green. At 6a07be61 it ran dispatch-gates --ran (99, all exit 0), lint and the three typechecks, with 5 ablations red as expected.

  • Carried to later PRs (the review's ③, none blocking):

  • Rulings since review, for PRs 2 and 4: Q3 = A (6676408264) and Q5 = A (6676415868). Their spec hunks wait for spec consent; all spec seats are vacant, and the maintainer has been asked.

  • Contract: Clause-②: yes (widening), Metadata-protocol: added, changeset minor on all five packages. Skills-docs: follow-up owed (skills/objectstack-api/SKILL.md :268, :417); the seat files that card, per the plan.

  • Landing: ready, then auto-merge, through the relay. The card stays open for PRs 2 to 5.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 04:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 04:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42163874310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > is green against a verbatim copy of the shipped ledgers
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > stays green when the missing path is attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > never bounds a citation attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > prints the citation count and how many are in range, in the documented two
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > stays GREEN on the drifted BEFORE-state — the honest residual this grammar e
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > prints the anchor count and how many resolve, equal on a green run
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > stays GREEN when a `dead` entry carries the SAME rotted pointe
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > declares every status either scanned or explicitly unscanned, 
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on the shipped ledgers and publishes the population be
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on a planted live-elsewhere row and counts it as carry
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the README state table (#7257) > is green against a verbatim copy, and says how many rows it checked
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the generated count artifact (#7377, sharded #20361) > is green against a verbatim copy, says the shar
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 15 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data protocol:system size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants